diff options
Diffstat (limited to 'app/api/partners/tbe/[sessionId]/documents/route.ts')
| -rw-r--r-- | app/api/partners/tbe/[sessionId]/documents/route.ts | 275 |
1 files changed, 275 insertions, 0 deletions
diff --git a/app/api/partners/tbe/[sessionId]/documents/route.ts b/app/api/partners/tbe/[sessionId]/documents/route.ts new file mode 100644 index 00000000..0045ea43 --- /dev/null +++ b/app/api/partners/tbe/[sessionId]/documents/route.ts @@ -0,0 +1,275 @@ +// app/api/partners/tbe/[sessionId]/documents/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import db from "@/db/db" +import { + rfqLastTbeDocumentReviews, + rfqLastTbeSessions, + rfqLastTbeHistory, + rfqLastTbeVendorDocuments +} from "@/db/schema" +import { eq, and } from "drizzle-orm" +import { writeFile, mkdir } from "fs/promises" +import { createWriteStream } from "fs" +import { pipeline } from "stream/promises" +import path from "path" +import { v4 as uuidv4 } from "uuid" + +// 1GB 파일 지원을 위한 설정 +export const config = { + api: { + bodyParser: { + sizeLimit: '1gb', + }, + responseLimit: false, + }, +} + +// 스트리밍으로 파일 저장 +async function saveFileStream(file: File, filepath: string) { + const stream = file.stream() + const writeStream = createWriteStream(filepath) + await pipeline(stream, writeStream) +} + +// POST: TBE 문서 업로드 +export async function POST(request: NextRequest, { params }: { params: { sessionId: string } }) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = Number(params.sessionId) + const formData = await request.formData() + + // ✅ 프런트 기frfqLastTbeVendorDocuments본값 'other' 등을 안전한 enum으로 매핑 + const documentType = (formData.get("documentType") as string | undefined) + const documentName = (formData.get("documentName") as string | undefined)?.trim() || "Untitled" + const description = (formData.get("description") as string | undefined) || "" + const file = formData.get("file") as File | null + + if (!file) { + return NextResponse.json({ error: "파일이 필요합니다" }, { status: 400 }) + } + + // 세션/권한 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { vendor: true }, + }) + if (!tbeSession) return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + + // 권한 체크: 회사 기준으로 통일 (위/아래 GET도 동일 기준을 권장) + if (tbeSession.vendor?.id !== session.user.companyId) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // 저장 경로 + const isDev = process.env.NODE_ENV === "development" + const uploadDir = isDev + ? path.join(process.cwd(), "public", "uploads", "tbe", String(tbeSessionId), "vendor") + : path.join(process.env.NAS_PATH || "/nas", "uploads", "tbe", String(tbeSessionId), "vendor") + + await mkdir(uploadDir, { recursive: true }) + + const safeOriginal = file.name.replace(/[^a-zA-Z0-9.\-_\s]/g, "_") + const filename = `${uuidv4()}_${safeOriginal}` + const filepath = path.join(uploadDir, filename) + + try { + if (file.size > 50 * 1024 * 1024) { + await saveFileStream(file, filepath) + } else { + const buffer = Buffer.from(await file.arrayBuffer()) + await writeFile(filepath, buffer) + } + } catch (e) { + console.error("파일 저장 실패:", e) + return NextResponse.json({ error: "파일 저장에 실패했습니다" }, { status: 500 }) + } + + // 트랜잭션 + const result = await db.transaction(async (tx) => { + // 1) 벤더 업로드 문서 insert + const [vendorDoc] = await tx + .insert(rfqLastTbeVendorDocuments) + .values({ + tbeSessionId, + documentType, // enum 매핑된 값 + isResponseToReviewId: null, // 필요 시 formData에서 받아 세팅 + fileName: filename, + originalFileName: file.name, + filePath: `/uploads/tbe/${tbeSessionId}/vendor/${filename}`, + fileSize: Number(file.size), + fileType: file.type || null, + documentNo: null, + revisionNo: null, + issueDate: null, + description, + submittalRemarks: null, + reviewRequired: true, + reviewStatus: "pending", + submittedBy: session.user.id, + submittedAt: new Date(), + reviewedBy: null, + reviewedAt: null, + reviewComments: null, + }) + .returning() + + // 2) (선택) 기존 리뷰 테이블에도 “벤더가 올린 검토대상 문서”로 남기고 싶다면 유지 + // 필요 없다면 아래 블록은 제거 가능 + const [documentReview] = await tx + .insert(rfqLastTbeDocumentReviews) + .values({ + tbeSessionId, + vendorAttachmentId:vendorDoc.id, + documentSource: "vendor", + documentType: documentType, // 동일 매핑 + documentName: documentName, // UX 표시용 이름 + reviewStatus: "미검토", + reviewComments: description, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + // 3) 세션 상태 전환 + if (tbeSession.status === "준비중") { + await tx + .update(rfqLastTbeSessions) + .set({ + status: "진행중", + actualStartDate: new Date(), + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .where(eq(rfqLastTbeSessions.id, tbeSessionId)) + } + + // 4) 이력 + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "document_review", + changeDescription: `벤더 문서 업로드: ${documentName}`, + changeDetails: { + vendorDocumentId: vendorDoc.id, + documentReviewId: documentReview.id, + documentName: documentName, + documentType: documentType, + filePath: vendorDoc.filePath, + }, + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + + if (tbeSession.status === "준비중") { + await tx.insert(rfqLastTbeHistory).values({ + tbeSessionId, + actionType: "status_change", + previousStatus: "준비중", + newStatus: "진행중", + changeDescription: "벤더 문서 업로드로 인한 상태 변경", + performedBy: session.user.id, + performedByType: "vendor", + performedAt: new Date(), + }) + } + + return { + vendorDoc, + documentReview, + } + }) + + return NextResponse.json({ + success: true, + data: { + vendorDocumentId: result.vendorDoc.id, + filePath: result.vendorDoc.filePath, + originalFileName: result.vendorDoc.originalFileName, + fileSize: result.vendorDoc.fileSize, + fileType: result.vendorDoc.fileType, + }, + message: "문서가 성공적으로 업로드되었습니다", + }) + } catch (error) { + console.error("TBE 문서 업로드 오류:", error) + return NextResponse.json({ error: "문서 업로드에 실패했습니다" }, { status: 500 }) + } +} + +// GET: TBE 세션의 문서 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { sessionId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || session.user.domain !== "partners") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const tbeSessionId = parseInt(params.sessionId) + + // TBE 세션 확인 및 권한 체크 + const tbeSession = await db.query.rfqLastTbeSessions.findFirst({ + where: eq(rfqLastTbeSessions.id, tbeSessionId), + with: { + vendor: true, + documentReviews: { + orderBy: (reviews, { desc }) => [desc(reviews.createdAt)], + } + } + }) + + if (!tbeSession) { + return NextResponse.json({ error: "TBE 세션을 찾을 수 없습니다" }, { status: 404 }) + } + + // 벤더 권한 확인 + if (tbeSession.vendor.userId !== session.user.id) { + return NextResponse.json({ error: "권한이 없습니다" }, { status: 403 }) + } + + // PDFTron 코멘트 수 집계 (필요시) + const documentsWithDetails = await Promise.all( + tbeSession.documentReviews.map(async (doc) => { + // PDFTron 코멘트 수 조회 + const pdftronComments = await db.query.rfqLastTbePdftronComments.findFirst({ + where: eq(rfqLastTbePdftronComments.documentReviewId, doc.id), + }) + + return { + ...doc, + comments: pdftronComments?.commentSummary || { + totalCount: 0, + openCount: 0, + }, + } + }) + ) + + return NextResponse.json({ + success: true, + session: { + id: tbeSession.id, + sessionCode: tbeSession.sessionCode, + sessionTitle: tbeSession.sessionTitle, + sessionStatus: tbeSession.status, + evaluationResult: tbeSession.evaluationResult, + }, + documents: documentsWithDetails, + }) + + } catch (error) { + console.error("문서 목록 조회 오류:", error) + return NextResponse.json( + { error: "문서 목록 조회에 실패했습니다" }, + { status: 500 } + ) + } +}
\ No newline at end of file |
